// Copyright 2012 Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package com.google.api.ads.tools.jaxws;

import com.google.common.base.Joiner;

import org.w3c.dom.Document;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;

import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.Writer;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpression;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory;

/**
 * A single service WSDL from a Google Ads product's API. Use the
 * {@link Wsdl.Builder} class to create instances.
 *
 * @author Joseph DiLallo
 */
public class Wsdl {

  private String packagePrefix;
  private String version;
  private String group;
  private String url;
  private Document wsdlDocument;
  private XPath xPath;

  /**
   * Private default constructor. Use the {@link Wsdl.Builder} class to create
   * instances.
   */
  private Wsdl() {}

  public String getUrl() {
    return url;
  }

  public String getVersion() {
    return version;
  }

  public Document getWsdlDocument() {
    return wsdlDocument;
  }

  public XPath getXPath() {
    return xPath;
  }

  /**
   * Gets the package this WSDL's autogenerated classes should belong to.
   */
  public String getTargetPackage() {
    return Joiner.on(".").skipNulls().join(packagePrefix, version, group);
  }

  /**
   * Writes the bindings necessary to map this WSDL's operation elements.
   *
   * @param bindingsFile the binding file which contains this WSDL's bindings
   * @throws XPathExpressionException if something goes wrong while searching
   * @throws IOException if the binding file cannot be written to
   */
  public void writeOperationElementBindings(BindingsFile bindingsFile)
      throws XPathExpressionException, IOException {
    List<String> operationElements = locateOperationElements();
    String portTypeName = getPortTypeName();

    for (String element : operationElements) {
      bindingsFile.writeElementBinding(element, portTypeName + element);
    }
  }

  /**
   * Writes the bindings necessary to map this WSDL's schemas to the desired
   * package. Writes one binding per schema node.
   *
   * @param bindingsFile the binding file which contains this WSDL's bindings
   * @throws XPathExpressionException if something goes wrong while searching
   * @throws IOException if the binding file cannot be written to
   */
  public void writeSchemaBindings(BindingsFile bindingsFile)
      throws XPathExpressionException, IOException {
    NodeList schemaNodes = (NodeList) xPath.evaluate(
        "/wsdl:definitions/wsdl:types/xsd:schema", wsdlDocument,
        XPathConstants.NODESET);
    for (int i = 0; i < schemaNodes.getLength(); i++) {
      String schemaTargetNamespace = schemaNodes.item(i).getAttributes().getNamedItem(
          "targetNamespace").getNodeValue();
      bindingsFile.writeSchemaPackageBinding(schemaTargetNamespace,
          generatePackageNameFromTargetNamespace(schemaTargetNamespace));
    }
  }

  /**
   * Writes the binding necessary to map this WSDL's elements which are not
   * within a schema definition node to the desired package.
   *
   * @param bindingsFile the binding file which contains this WSDL's bindings
   * @throws IOException if the binding file cannot be written to
   */
  public void writeNonSchemaBinding(BindingsFile bindingsFile) throws IOException {
    bindingsFile.writeNonSchemaPackageBinding(getTargetPackage());
  }

  /**
   * Merges another WSDL's schema definitions into this WSDL.
   *
   * @param wsdlToAdd The WSDL to be merged
   * @throws IOException if a bindings file cannot be written
   * @throws SAXException if a WSDL cannot be parsed
   * @throws XPathExpressionException if something goes wrong while searching
   */
  public void mergeInWsdl(Wsdl wsdlToAdd)
          throws IOException, SAXException, XPathExpressionException {
    NodeList wsdlToAddSchemaNodes = (NodeList) xPath.evaluate(
        "/wsdl:definitions/wsdl:types/xsd:schema", wsdlToAdd.getWsdlDocument(),
        XPathConstants.NODESET);
    for (int n = 0; n < wsdlToAddSchemaNodes.getLength(); n++) {
      mergeInSchemaNode(wsdlToAdd.locateOperationElements(), wsdlToAddSchemaNodes.item(n));
    }
  }

  /**
   * Merges one schema definition node into this WSDL.
   *
   * @param wsdlToAddOperationElements The elements relating to the operations
   *        from the WSDL to be merged
   * @param schemaNode The schema node to be merged in
   * @throws XPathExpressionException if something goes wrong while searching
   * @throws IOException if a bindings file cannot be written
   */
  private void mergeInSchemaNode(List<String> wsdlToAddOperationElements, Node schemaNode)
      throws XPathExpressionException, IOException {
    String schemaTargetNamespace =
        schemaNode.getAttributes().getNamedItem("targetNamespace").getNodeValue();

    Node existingSchema = (Node) xPath.evaluate(
        "/wsdl:definitions/wsdl:types/xsd:schema[@targetNamespace ='"
        + schemaTargetNamespace + "']", wsdlDocument, XPathConstants.NODE);

    if (existingSchema == null) {
      adoptSchemaForNewNamespace(wsdlToAddOperationElements, schemaNode);
    } else {
      mergeInSchemaForExistingNamespace(schemaNode, existingSchema);
    }
  }

  /**
   * Merges in a node that defines the schema for a namespace which is already
   * (partially) defined by another schema node in this WSDL.
   *
   * @param schemaNode The schema node to be merged
   * @param masterSchemaNode The schema node that the other {@code Node} will
   *        be merged into
   * @throws XPathExpressionException if something goes wrong while searching
   */
  private void mergeInSchemaForExistingNamespace(Node schemaNode, Node masterSchemaNode)
      throws XPathExpressionException {
    adoptNewAttributes(schemaNode.getAttributes(), masterSchemaNode.getAttributes());

    NodeList schemaChildrenToAdd = (NodeList) xPath.evaluate(
        "/wsdl:definitions/wsdl:types/xsd:schema[@targetNamespace ='"
        + schemaNode.getAttributes().getNamedItem("targetNamespace").getNodeValue()
        + "']/*[not(self::xsd:element)]",
        schemaNode, XPathConstants.NODESET);
    mergeChildrenIntoSchemaNode(masterSchemaNode, schemaChildrenToAdd);
  }

  /**
   * Adopts any attributes on the schema definition node that do not already
   * exist.
   *
   * We do this primarily to pick up any new namespace definitions which the
   * existing schema node does not already use.
   *
   * @param schemaToAddAttrs Attributes from the schema node being merged in
   * @param existingSchemaAttrs Attributes from the existing schema node
   */
  private void adoptNewAttributes(NamedNodeMap schemaToAddAttrs,
      NamedNodeMap existingSchemaAttrs) {
    for (int i = 0; i < schemaToAddAttrs.getLength(); i++) {
      Node attr = schemaToAddAttrs.item(i);
      if (existingSchemaAttrs.getNamedItem(attr.getNodeName()) == null) {
        Node newNode = wsdlDocument.importNode(attr, true);
        newNode.setNodeValue(attr.getNodeValue());
        existingSchemaAttrs.setNamedItem(newNode);
      }
    }
  }

  /**
   * Takes the children from one schema node and merges them into a different
   * schema node.
   *
   * @param schemaNode The schema node that the other {@code Node}s will be
   *        merged into
   * @param schemaChildrenToAdd The {@code Node}s to be merged into the schema
   * @throws XPathExpressionException if something goes wrong while searching
   */
  private void mergeChildrenIntoSchemaNode(Node schemaNode, NodeList schemaChildrenToAdd)
      throws XPathExpressionException {
    for (int i = 0; i < schemaChildrenToAdd.getLength(); i++) {
      mergeChildIntoSchemaNode(schemaNode, schemaChildrenToAdd.item(i));
    }
  }

  /**
   * Takes a child node and adds it as a child to the given schema node. Ignores
   * annotation and import nodes.
   *
   * @param existingSchema The schema node that the other {@code Node} will be
   *        merged into
   * @param childToAdd The {@code Node} to be merged into the schema
   * @throws XPathExpressionException if something goes wrong while searching
   */
  private void mergeChildIntoSchemaNode(Node existingSchema, Node childToAdd)
      throws XPathExpressionException {
    if (childToAdd.getNodeName().equals("annotation") ||
        childToAdd.getNodeName().equals("xsd:import")) {
      return;
    }

    Node nameAttr = childToAdd.getAttributes().getNamedItem("name");
    NodeList nodesWithSameName = (NodeList) xPath.evaluate(
        "/wsdl:definitions/wsdl:types/xsd:schema/xsd:" + childToAdd.getNodeName()
        + "[@name='" + nameAttr.getNodeValue() + "']",
        existingSchema, XPathConstants.NODESET);

    if (nodesWithSameName.getLength() == 0) {
      existingSchema.appendChild(wsdlDocument.importNode(childToAdd, true));
    }
  }

  /**
   * Adds a node that defines the schema for a namespace which is not already
   * defined by another schema node in this WSDL. Removes some unnecessary nodes
   * from the new schema definition first.
   *
   * @param wsdlToAddOperationElements The elements relating to the operations
   *        from the WSDL to be merged
   * @param schemaNode The schema node to be merged
   * @throws XPathExpressionException if something goes wrong while searching
   */
  private void adoptSchemaForNewNamespace(
      List<String> wsdlToAddOperationElements, Node schemaNode)
      throws XPathExpressionException {

    removeAnnotationNode(schemaNode);
    for (String element : wsdlToAddOperationElements) {
      removeElementIfExists(schemaNode, element);
    }

    Node masterTypesNode = (Node) xPath.evaluate(
        "/wsdl:definitions/wsdl:types", wsdlDocument, XPathConstants.NODE);
    masterTypesNode.appendChild(wsdlDocument.importNode(schemaNode, true));
  }

  /**
   * Removes the element with the given name if it exists in the given schema
   * node. If the element does not exist, the schema is unchanged.
   *
   * @param schemaNode The schema node to remove elements from
   * @param element The name of the element node to remove from the schema
   * @throws XPathExpressionException if something goes wrong while searching
   */
  private void removeElementIfExists(Node schemaNode, String element)
      throws XPathExpressionException {
    Node operationElementNode = (Node) xPath.evaluate(
        "/wsdl:definitions/wsdl:types/xsd:schema[@targetNamespace ='"
        + schemaNode.getAttributes().getNamedItem("targetNamespace").getNodeValue()
        + "']/xsd:element[@name='" + element + "']",
        schemaNode, XPathConstants.NODE);
    if (!(operationElementNode == null)) {
      schemaNode.removeChild(operationElementNode);
    }
  }

  /**
   * Removes the annotation node from a schema node.
   *
   * @param schemaNode The schema node to modify
   * @throws XPathExpressionException if something goes wrong while searching
   */
  private void removeAnnotationNode(Node schemaNode)
      throws XPathExpressionException {
    Node schemaNodeAnnotationChild = (Node) xPath.evaluate(
        "/wsdl:definitions/wsdl:types/xsd:schema[@targetNamespace ='"
        + schemaNode.getAttributes().getNamedItem("targetNamespace").getNodeValue()
        + "']/xsd:annotation", schemaNode, XPathConstants.NODE);
    schemaNode.removeChild(schemaNodeAnnotationChild);
  }

  /**
   * Writes a {@code Document} to a file.
   *
   * @param filename The path to output to
   * @throws TransformerException if the {@code Document} cannot be transformed
   *         to XML
   * @throws IOException if the {@code Document} cannot be written to a file
   */
  public void outputDocument(String filename) throws TransformerException, IOException {
    outputDocument(new FileWriter(new File(filename)));
  }

  /**
   * Writes a {@code Document} to a {@code Writer}.
   *
   * @param writer The writer to output to
   * @throws TransformerException if the {@code Document} cannot be transformed
   *         to XML
   * @throws IOException if the {@code Document} cannot be written to the writer
   */
  public void outputDocument(Writer writer) throws TransformerException, IOException {
    StreamResult filestream = new StreamResult(writer);
    TransformerFactory transformerFactory = TransformerFactory.newInstance();
    Transformer transformer = transformerFactory.newTransformer();
    DOMSource domSource = new DOMSource(wsdlDocument);
    transformer.transform(domSource, filestream);
  }

  /**
   * Fetches the name attribute of the first wsdl:portType node in a WSDL. For
   * Google's Ads APIs, there is only one wsdl:portType node per WSDL.
   *
   * @return The name attribute of the first wsdl:portType node in this WSDL
   * @throws XPathExpressionException if something goes wrong while searching
   */
  private String getPortTypeName() throws XPathExpressionException {
    Node inputNode = (Node) xPath.evaluate(
        "/wsdl:definitions/wsdl:portType", wsdlDocument, XPathConstants.NODE);
    return inputNode.getAttributes().getNamedItem("name").getNodeValue();
  }

  /**
   * Finds the name attributes for the element nodes which define the input and
   * outputs of every operation in the WSDL. This only works for
   * Document/Literal style WSDLs.
   *
   * @return A list of the name attributes for all operation input/output elements
   * @throws XPathExpressionException if something goes wrong while searching
   */
  private List<String> locateOperationElements() throws XPathExpressionException {
    XPathExpression getOperations = xPath.compile(
        "/wsdl:definitions/wsdl:portType/wsdl:operation");
    NodeList operationNodes = (NodeList) getOperations.evaluate(wsdlDocument,
        XPathConstants.NODESET);
    List<String> operationElements = new ArrayList<String>();
    for (int i = 0; i < operationNodes.getLength(); i++) {
      operationElements.addAll(locateElementsForOperation(operationNodes.item(i)));
    }
    return operationElements;
  }

  /**
   * Finds the name attributes for the element nodes which define the input and
   * outputs of the given operation Node in the WSDL. This only works for
   * Document/Literal style WSDLs.
   *
   * @param node The operation node to find the elements nodes for
   * @return A list of the name attributes for the input/output elements of this
   *         operation
   * @throws XPathExpressionException if something goes wrong while searching
   */
  private List<String> locateElementsForOperation(Node node)
      throws XPathExpressionException {
    List<String> operationElements = new ArrayList<String>();

    String operationName = node.getAttributes().getNamedItem("name").getNodeValue();
    Node inputNode = (Node) xPath.evaluate(
        "/wsdl:definitions/wsdl:portType/wsdl:operation[@name ='" + operationName
        + "']/wsdl:input", node, XPathConstants.NODE);
    Node outputNode = (Node) xPath.evaluate(
        "/wsdl:definitions/wsdl:portType/wsdl:operation[@name ='" + operationName
        + "']/wsdl:output", node, XPathConstants.NODE);
    String inputMessageName = inputNode.getAttributes().getNamedItem("message").getNodeValue();
    String outputMessageName = outputNode.getAttributes().getNamedItem("message").getNodeValue();

    operationElements.add(getElementNameForOperation(inputMessageName));
    operationElements.add(getElementNameForOperation(outputMessageName));

    return operationElements;
  }

  /**
   * Helper method which uses xpath to take a wsdl:message name attribute, find
   * the wsdl:part nested in the wsdl:message with the given name, and returns
   * the element attribute of that wsdl:part.
   *
   * @param messageName The name attribute of the target wsdl:message
   * @return The name of the element attribute from the wsdl:part nested in the
   *         wsdl:message with the given name attribute
   * @throws XPathExpressionException if something goes wrong with searching
   */
  public String getElementNameForOperation(String messageName) throws XPathExpressionException {
    int indexOfColon = messageName.indexOf(":");
    String messageElementName = messageName.substring(indexOfColon + 1);
    Node messageNodePart = (Node) xPath.evaluate(
        "/wsdl:definitions/wsdl:message[@name ='" + messageElementName + "']/wsdl:part",
        wsdlDocument, XPathConstants.NODE);
    String messagePartElement =
        messageNodePart.getAttributes().getNamedItem("element").getNodeValue();
    indexOfColon = messagePartElement.indexOf(":");
    return messagePartElement.substring(indexOfColon + 1);
  }

  /**
   * Takes the targetNamespace attribute of a schema definition and returns the
   * package name to use for POJOs generated from it.
   *
   * @param schemaTargetNamespace The attribute from the schema node
   * @return The name of the package to use for this schema's POJOs.
   */
  public String generatePackageNameFromTargetNamespace(String schemaTargetNamespace) {
    String[] splitNamespace = schemaTargetNamespace.split("/");
    String version = splitNamespace[splitNamespace.length - 1];
    String group = splitNamespace[splitNamespace.length - 2];
    String packageName = packagePrefix + "." + version;
    if (this.group != null) {
      packageName += "." + group;
    }
    return packageName;
  }

  /**
   * Builder class used to create {@code Wsdl} objects.
   *
   * @author Joseph DiLallo
   */
  public static class Builder {

    Wsdl wsdl;
    DocumentBuilder builder;

    public Builder() {
      this.wsdl = new Wsdl();
    }

    public Builder withDocumentBuilder(DocumentBuilder builder) {
      this.builder = builder;
      return this;
    }

    public Builder withUrl(String url) {
      wsdl.url = url;
      return this;
    }

    public Builder withWsdlDocument(Document wsdlDocument) {
      wsdl.wsdlDocument = wsdlDocument;
      return this;
    }

    public Builder withXPath(XPath xPath) {
      wsdl.xPath = xPath;
      return this;
    }

    public Builder withPackagePrefix(String packagePrefix) {
      wsdl.packagePrefix = packagePrefix;
      return this;
    }

    public Builder withVersion(String version) {
      wsdl.version = version;
      return this;
    }

    public Builder withGroup(String group) {
      wsdl.group = group;
      return this;
    }

    /**
     * Builds and returns the in-progress {@code Wsdl}.
     *
     * @return the {@code Wsdl} ready for use
     * @throws MalformedURLException if the WSDL url given was not valid
     * @throws IOException if the WSDL could not be opened for reading
     * @throws SAXException if the given WSDL could not be parsed
     */
    public Wsdl build() throws MalformedURLException, IOException, SAXException {
      validate(wsdl);

      if (wsdl.wsdlDocument == null) {
        if (builder == null) {
          DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
          factory.setNamespaceAware(true);
          try {
            builder = factory.newDocumentBuilder();
          } catch (ParserConfigurationException e) {
            throw new IllegalStateException("Failed to create a DocumentBuilder.", e);
          }
        }
        wsdl.wsdlDocument = builder.parse(new URL(wsdl.url).openStream());
      }

      if (wsdl.xPath == null) {
        wsdl.xPath = XPathFactory.newInstance().newXPath();
      }

      return wsdl;
    }

    /**
     * Validates the {@code Wsdl} being built has the necessary parameters set.
     *
     * @param wsdl The {@code Wsdl} to validate
     * @throws IllegalStateException if the {@code Wsdl} being built is not
     *         valid
     */
    public void validate(Wsdl wsdl) {
      if (wsdl.url == null) {
        throw new IllegalStateException("You must set the url on a Wsdl.Builder in order "
            + "to build a Wsdl.");
      } else if (wsdl.packagePrefix == null) {
        throw new IllegalStateException("You must set the package prefix on a Wsdl.Builder in "
            + "order to build a Wsdl.");
      } else if (wsdl.version == null) {
        throw new IllegalStateException("You must set the version on a Wsdl.Builder in order "
            + "to build a Wsdl.");
      }
    }
  }
}
